| # | buildtime 的一步 | 實際做了什麼 | 主場筆記 |
|---|---|---|---|
| 1 | 觸發 | 你打 npm run build。run 是 npm 的子指令,意思是「去 scripts 物件找這個 key,把對應字串丟給系統 shell 執行」,不是 npm 自動幫你加的 |
[[08-npm-run-script-mechanism]] |
| 2 | 找得到工具 | npm 執行 script 前會把 node_modules/.bin 塞進 PATH,所以 "build": "vite build" 可以直接寫套件名、不用寫完整路徑 |
[[09-npm-scripts-pre-post-生命週期鉤子]] 第 l 點 |
| 3 | 前置鉤子 prebuild |
npm 自動先跑同名 pre 腳本(常見用途:rimraf dist 清掉上次產物、先跑一次 lint 或 type check) |
[[09-npm-scripts-pre-post-生命週期鉤子]] 第 e、f 點 |
| 4 | 建立依賴圖(其實跟轉譯是交織在一起的) | bundler 從入口檔(main.tsx/index.js)開始:先用 parser(如 acorn)parse 每個檔、讀出它有哪些 import,再用 resolver(如 webpack 的 enhanced-resolve、Rollup 的 @rollup/plugin-node-resolve)resolve 那些 import 指到的實際檔案(含去 node_modules 找第三方),一路遞迴掃出整張 module graph。parse 與 resolve 一樣重要:一個讀出「要什麼」、一個找出「在哪」 |
本篇第 1、6 節 |
| 5 | 轉譯 transpile | Babel/tsc/SWC 把 TSX、JSX、新語法轉成標準 JS。方向是高階→高階,所以叫轉譯不叫編譯 |
本篇第 1 節(5 步表)、6 節 |
| 6 | 品質把關 | ESLint 抓邏輯問題與潛在 bug,Prettier 統一格式。實務上常掛在 prebuild 或 CI,不是打包器本身的職責 |
本篇第 3 節 + 上面第 3 步 |
| 7 | 壓縮 minify | 移除註解、空白、換行,縮短變數名 | 本篇第 1 節(5 步表) |
| 8 | 打包 bundle | 把多個模組合併成少數幾支檔案,做 code splitting 與帶 hash 的檔名 | 本篇第 1 節(5 步表) + [[10-next-turbopack-server-chunks-hash-comparison]] |
| 9 | 後置鉤子 postbuild |
build 成功結束才跑;build 失敗(非 0 退出碼)時不會執行 |
[[09-npm-scripts-pre-post-生命週期鉤子]] 第 f 點 |
| 10 | 交棒 | 產物丟上伺服器。buildtime 到這裡就結束了,瀏覽器下載到的是第 8 步的產物,之後才輪到 V8 的 runtime 編譯 | [[04-V8引擎完整管線-Parse到Deoptimization-【編譯runtime】|04-V8引擎完整管線(編譯 runtime)]] |
[!tip]- 一句話記法
08 教你run怎麼找到指令 → 09 教你指令前後還會偷跑什麼 → 03(本篇)教你被跑起來的那些工具各自在做什麼 → 04 才是瀏覽器拿到產物之後的事。
為什麼要打包:開發碼含偵錯工具、未壓縮、好讀註解,且瀏覽器不認得 JSX/TS;build 時要處理成瀏覽器能吃的靜態檔。這 5 步就是最上面地圖第 4–8、10 步的細節(跟 [[script載入方式+前因後果]] 的打包 5 步一致):
分兩階段:A 內部交織、不分先後;B 才有順序(所以不硬編 1→2→3→4→5)。
| 階段 | 動作 | 誰做 | 做什麼 → 產物 |
|---|---|---|---|
| A. 分析+轉譯(同一趟、交織) | 建相依圖 | bundler core | acorn parse 找 import + enhanced-resolve resolve 找實際檔(含 node_modules)→ module graph |
| A(同一趟) | 逐檔 transpile | Babel/SWC/esbuild | 每讀到一個 .tsx/.jsx 就轉成標準 JS(否則 parse 不出它的 import)→ 純文字 JS |
| B. 合成+輸出(有先後) | bundle | bundler core | 合併成 chunk + tree-shaking(砍死碼)+ code-splitting(切 vendor/lazy)+ scope-hoisting → 少數幾支 JS |
| B | minify | Terser/esbuild/SWC | 去空白/註解、縮短變數名 → 更小的 JS |
| B | hash + 產出 | bundler core | 檔名帶 content hash(main-a1b2c3.js)+把 <script>/<link> 注入 index.html → dist/ 靜態檔 |
記法:與其背 ①→②→③→④→⑤,不如記兩個階段——A「先一趟把碼讀懂+統一成標準 JS」(建圖與轉譯交織、同一趟,不是先做完①才做②)→ B「再合成輸出」(bundle→minify→hash,這三個才真的有先後)。
content hash 為什麼是必要的第 5 步(你上次問的):內容沒變→檔名不變→瀏覽器直接用快取;內容一變→檔名就變→強制重抓(cache busting)。這不是「打包三大任務」漏寫,而是本來就有的第 5 步。
為什麼「建圖①」後馬上「轉譯②」、又擺在 bundle/minify 前面?
- 轉譯不是「去掉東西」——去死碼是③ tree-shaking、去空白是④ minify。轉譯是把語法翻成標準 JS(
JSX→createElement、TS→JS;唯一算「去掉」的是 TS 型別註記)。- 建圖①與轉譯②其實交織、同一趟走訪:bundler 要建圖得讀每個檔的
import,但.tsx/.jsx不是合法標準 JS,每讀到一個檔就先轉譯成標準 JS,才 parse 得出它的 import、把圖往下接。所以是「邊建圖邊逐檔轉譯」,不是整張圖建完才轉。- 轉譯要早(在③④前):因為 tree-shaking 要分析 ES
import/export、minify 要處理 JS——都得先把大家統一成標準 JS 才做得了。
開發碼未壓縮、好讀;為上線快又穩,用打包工具做「生產(production)打包」——也就是上面那 5 步。下表比較各工具。
[!note] 看到「This page is using the production build of React」就代表:這網頁用的是經打包工具優化後的 React,已準備好上線,而非開發模式。
| 工具(問世) | 語言 | 類型 | 特點 / 2026 現況 |
|---|---|---|---|
| Webpack(2012) | JS | bundler | 歷史悠久、生態成熟、所有框架通用;但 HMR 較慢、需大量 Loaders/Plugins;下載量仍最多但新專案少選 |
| Rollup(2015) | JS | bundler | 推廣 tree-shaking、ESM-first;早期偏函式庫打包;曾是 Vite 生產打包的底層 |
| esbuild(2020) | Go | transpiler/bundler | 極快,多被當「其他工具內部的轉譯器」;Vite 開發期用它做預轉譯 |
| Vite(2020) | JS(底層借 Go/Rust) | build tool(含 dev server+bundler) | 開發用原生 ESM+esbuild 不 bundle、生產用 Rollup(Vite v8 起改 Rolldown);2026 新專案預設 |
| Turbopack(2022) | Rust | bundler | Vercel 做、增量只重跑變動部分;Next.js 專用,next dev --turbo |
| Rolldown(2024→Vite v8, 2026) | Rust | bundler | Rollup 的 Rust 重寫;Vite v8 起取代 esbuild+Rollup |
| Next.js(2016) | —(JS 框架) | 框架,不是 bundler | 底層用 Turbopack/Webpack 這些 bundler,另外加路由/SSR/資料抓取 |
⚠️ 主詞分清:Webpack/Rollup/esbuild/Turbopack/Rolldown 是 bundler(打包器);Vite 是 build tool(把 bundler + dev server 包成一套);Next.js 是框架(把某個 bundler 再包一層、加路由/SSR)。所以「為何不比較 Next.js」——它跟前面那些不是同一層,它是用它們的人。
表格裡你看不懂的三點,講清楚:
兩者解決的問題完全不同、常一起用。左右對照(兩者都在最上面地圖的第 6 步「品質把關」,不是打包器本身的職責):
| ESLint(靜態分析 Linting)=文法老師 | Prettier(格式化 Formatting)=美妝師 | |
|---|---|---|
| 關注 | 程式碼品質與潛在問題 | 程式碼外觀 |
| 具體管什麼 | ① 語法錯誤 ② 不好的實踐(bad practice) ③ 潛在 bug ④ 未使用變數 | 縮排、換行、括號、分號、單雙引號 |
| 規則 | 高度可客製 | 極少、幾乎免設定 |
| 動作 | 抓出問題(可 --fix 部分自動修) |
一鍵重排格式 |
| 在流水線 | 第 6 步「品質把關」 | 第 6 步「品質把關」 |
(ESLint 底層會先用一個 parser 把碼讀成 AST 再套規則檢查——那個 parser 常是 espree/acorn/@typescript-eslint/parser,是第 6 步內部的實作零件,不是另一個獨立階段。這就是「提到 Acorn 時,它落在流水線第 6 步」。)
.eslintrc.json 是 ESLint 的設定檔,定義風格(分號、引號)、潛在錯誤規則、執行環境(瀏覽器 / Node)。rc = Run Commands,源自 Unix/Linux(如 .bashrc):程式啟動時自動讀取並執行此檔的設定。
瀏覽器擴充功能,像 React 網站的「X 光機」:
JS/TS 四大名牌:
資料格式內建款: JSON.parse()(JS 內建 JSON 解析器);V8 / SpiderMonkey(瀏覽器內建 HTML/CSS Parser)。
解析器產生器(Parser Generator) — 給語法規則就自動生 Parser:ANTLR(跨語言頂級,常用於 SQL/搜尋指令)、Bison / Yacc(C/C++ 編譯器課祖師爺)。
[!tip] 你寫的 React 程式碼交給 Acorn / Babel 拆解;後端那條
SELECT … FROM groupSQL 則常交給 ANTLR 寫出來的 SQL Parser 處理。
瀏覽器只看得懂原生 JS,看不懂 React 的 JSX 語法(例如 <div>{count}</div>),
所以 React 專案在跑起來之前要經過:
<h1 className="title">Hello</h1> 轉成 _jsx("h1", { className: "title", children: "Hello" }) 這種原生 JS 呼叫。.jsx/.js/CSS/圖片等整理、壓縮成瀏覽器看得懂的原生 JS。轉譯與打包邏輯上是「先轉譯、再打包」,但實務上交織進行:打包工具從入口檔開始建立依賴圖,每讀到一個 .jsx 檔就呼叫轉譯器把它轉成標準 JS,全部轉完後再合併壓縮輸出成最終 bundle。
關鍵在:build 有沒有「多跑一步:執行那些 transpile 後的 JS、把結果印成 HTML 字串」。
| 模式 | JSX 的下場 | build 產出 | 每頁有現成內容 HTML? | DOM 何時建 |
|---|---|---|---|---|
| CSR(Vite/CRA SPA) | transpile 成 JS(_jsx(...)),build 不執行它 |
一份空殼 index.html + 一堆 JS |
❌ 只有一份空殼 | runtime:瀏覽器跑 JS、用 document.createElement 現建 |
SSG(Next output:export、Astro、Gatsby) |
先 transpile 成 JS,build 時再「執行」那些 JS | 每條路由各一份有內容的 .html + JS |
✅ 每頁都有 | build 時用 renderToStaticMarkup/renderToString 印成 HTML 字串;瀏覽器 parse 成 DOM 後 hydration |
| SSR(Next.js) | 同上 transpile;每次請求在伺服器執行 JS | 伺服器每請求現印一份有內容 HTML | ✅(每請求現產) | 伺服器印 HTML → 瀏覽器 parse → hydration |
renderToString/renderToStaticMarkup 把 React 元件印成 HTML 字串——這才是 HTML 的來源。不是 JSX 直接變 HTML,是「執行 transpile 後的 JS」印出 HTML。
renderToStaticMarkup 產出 about.html、posts/1.html… → ③ 部署成純靜態檔(純靜態 CDN 就能發) → ④ 瀏覽器拿到有內容的 .html、跑 HTML Parsing 成 DOM → ⑤ 載入 JS 做 hydration 掛互動。跟 CSR 只差第 ② 步:SSG 在 build 就把 HTML 印好了。(CSR/SSR/SSG 對照見 [[script載入方式+前因後果]] 與 [[SPA架構-入口點-CSR客戶端效能與狀態-部署]]。)| 比較項目 | 原生 JavaScript | React |
|---|---|---|
| 語法 | 標準 JS,直接操作 DOM | JSX(JS+HTML 混合),需轉譯 |
| DOM 操作 | 直接操作真實 DOM(如 getElementById) |
虛擬 DOM,Diff 算法算出差異再統一更新 |
| 程式思維 | 命令式:一步步告訴瀏覽器怎麼做 | 聲明式:定義 State 與 UI 關係,畫面隨 State 自動更新 |
| 執行效能機制 | 每次改 DOM 立刻觸發重繪 | 記憶體中虛擬 DOM 比較,批次處理(Batching)更新 |
Scanner(詞法分析器 / Lexer) 是 JS 引擎編譯的第一階段,負責把原始碼字串拆成有意義的最小單位(Token)。例如 const count = 0; 會被拆成 const(關鍵字)、count(識別符)、=(指定運算子)、0(數字字面量)、;(分號)。JS 引擎處理流程大致是:Source Code → Scanner(字串→Tokens)→ Parser(Tokens→AST)→ Ignition 直譯器(AST→Bytecode 並執行)。
常見誤解:以為專案用了 React 就代表後端一定要用 Node.js。實際上「前端開發/打包用的 Node.js」跟「後端伺服器用的 Node.js」是兩件完全獨立的事:
fs、path 等瀏覽器 JS 沒有的權限)並執行。npm run build / npm run dev 時,其實是透過 Node.js 執行這些打包/轉譯工具;工具本身用 JS/C++/Rust/Go 寫成,在 Node.js 環境下把 JSX 原始碼轉譯打包成靜態 JS/CSS/HTML。dist//build/ 資料夾裡的靜態檔案。即使後端選型是 Python(Django/FastAPI)、Go、Java 等非 Node 語言,本機或 CI/CD 環境開發 React 前端時,依然需要安裝 Node.js 來跑打包工具——這跟後端用什麼語言完全不衝突。部署階段常見架構:
| 架構 | 說明 | 線上 Server 需要 Node.js 嗎? |
|---|---|---|
| 前後端完全分離 | 前端靜態檔放 Nginx/S3+CDN/Vercel;後端 Python API 獨立跑在別的 Server | 不需要 |
| Python 後端直接託管靜態檔 | 打包出的 index.html/JS/CSS 放進 Python 的 static/,由 Flask/Django 路由回傳 |
不需要 |
| SSR(如 Next.js) | 需要即時在伺服器用 JS 渲染 HTML | 需要(但後端是 Python 時通常不會選這種架構) |
一句話總結:後端選 Python,代表 API Server(Production Runtime)是 Python;前端打包用 Node.js,代表編譯工具(Development Build Tool)借用了 Node.js 環境——兩者是不同層次的分工,不衝突。
延伸:Babel/tsc 轉譯(本篇第 1、5、6 節)跟 V8 引擎本身的 Parse→Ignition→TurboFan 編譯管線是兩個不同層次、不同時間點的事,完整對照與 4 張「原生 JS vs React × 冷/熱路徑」流程圖見 [[04-V8引擎完整管線-Parse到Deoptimization-【編譯runtime】|04-V8引擎完整管線-Parse到Deoptimization(編譯 runtime)]]。
使用者: (貼上自己關於 Parser / Compiler / 打包工具 / React DevTools / ESLint vs Prettier 的零散筆記) 你覺得我這些筆記要怎麼整理比較好?
Gemini: 建議分四個區塊有系統地整理:
① 編譯與解析核心(Parser=讀懂拆解、Compiler=翻譯,第一步用 Parser);
② 深入打包工具(為何需要、編譯/壓縮/打包三任務、Webpack vs Turbopack);
③ 開發工具功用(React DevTools 像 X 光機:元件檢查器看 props/state、Profiler 分析渲染效能);
④ 程式碼品質工具區別(ESLint 文法老師管品質、Prettier 美妝師管格式)。一句話故事:Parser 讀懂 → Compiler 翻譯 → ESLint/Prettier 把關 → 打包工具上線優化 → DevTools 偵錯。
使用者: .eslintrc.json 是什麼樣的文件?rc 代表什麼?
Gemini: 它是 ESLint 的設定檔,定義風格(分號、單雙引號)、潛在錯誤(禁未使用變數)、執行環境(瀏覽器/Node)。rc = Run Commands(執行指令),源自 Unix/Linux(如 .bashrc):程式啟動時自動讀取執行此檔設定。
使用者: 解析器有哪些「牌子」?
Gemini: 技術上稱套件/工具/引擎。JS/TS 四大:Acorn(最流行輕量,Webpack/Rollup/ESLint 底層)、@babel/parser(JSX/實驗語法,基於 Acorn)、Esprima(經典老牌)、SWC/Biome(Rust 重寫求速,Next.js 用 SWC)。資料格式:JSON.parse()、瀏覽器內建 V8/SpiderMonkey。解析器產生器:ANTLR(跨語言頂級,SQL 常用)、Bison/Yacc(C/C++ 祖師爺)。
使用者:React 是怎麼去編譯的呢他跟原生 JS 差在哪 / 所以先轉譯才打包囉?Scanner 是啥 / 好像聽 YouTube 影片說是由 nodejs 編譯的欸還是我記錯了 / 但我一般在打包前端的時候我不一定會用到 nodejs 啊,萬一我的後端選型不是 nodejs 而是 Python 呢?
Gemini:說明 React 需經 JSX 轉譯(Babel/SWC)與模組打包(Vite/Webpack/ESBuild)才能被瀏覽器執行,並比較 React 與原生 JS 在 DOM 操作、程式思維、效能機制上的差異;解釋 Scanner 是 JS 引擎編譯第一階段的詞法分析器,把原始碼拆成 Token;澄清 Node.js 不是編譯器本身而是執行環境,讓 Babel/Webpack 等工具能讀寫檔案並執行;最後說明前端打包用 Node.js(開發期工具)與後端選型用 Python(Production Runtime)是兩個不衝突的獨立分工,並列出前後端分離/Python 託管靜態檔/SSR 三種常見部署架構。
網路層 HTTP response body
↓
1. input byte stream(位元組流) ← 網路層,還不是文字
↓
2. decoding(解碼)→ input stream ┐
↓ │
3. tokenization(標記化)→ tokens ├─ 這三步合起來 = HTML Parsing
↓ │
4. tree construction(樹建構) ┘
↓
DOM 樹(產物,不是第 5 步)
.html;後續追問請指回圖上的第幾步,不要另開新章節。| 說法 | 對錯 | 精確講法 |
|---|---|---|
| HTML 是純文字格式 | ✅ | 指內容語意,不是儲存或傳輸形式 |
| HTML 傳輸時不是 bytes | ❌ | 一定是 bytes |
| 拿到 response body 就是字串了 | ❌ | 拿到的是 byte stream,還要走步驟 2 |
<!DOCTYPE html> 在網路上的真面目:
字元: < ! D O C T Y P E (空白) h t m l >
bytes: 3C 21 44 4F 43 54 59 50 45 20 68 74 6D 6C 3E
Content-Length: 1834,這個數字的單位是 bytes 不是字數。中文一字在 UTF-8 佔 3 bytes,所以中文網頁的 bytes 數遠大於字數。| 比較項 | 字串 String | 流 Stream |
|---|---|---|
| 長度 | 有 .length,一開始就知道 |
不知道,可能還在下載 |
| 存取 | 可隨機索引、可回頭 | 只能依序消耗,讀過就過去 |
| 中途變長 | 不行 | 可以(網路 chunk、document.write()) |
| 存在哪 | 完整存在記憶體 | 邊來邊處理 |
renderToPipeableStream)能分段送 HTML,也是吃這個性質。<meta charset> 寫在 bytes 裡面,要讀它得先解碼,要解碼得先知道 charset —— 規範的解法是這個優先序:
| 順序 | 依據 | 說明 |
|---|---|---|
| 1 | BOM(Byte Order Mark) | UTF-8 是 EF BB BF。有就直接採信 |
| 2 | 使用者手動指定 | 瀏覽器選單強制指定 |
| 3 | HTTP header | Content-Type: text/html; charset=utf-8。優先於 meta |
| 4 | prescan 前 1024 bytes | 找 <meta charset>。這就是它必須放 head 最前面的原因 |
| 5 | 父文件 | iframe 同源時繼承 |
| 6 | 歷史紀錄 | 之前造訪偵測到的 |
| 7 | 自動偵測 | 統計特徵猜(規範不鼓勵) |
| 8 | 語系預設 | 猜錯就是亂碼 mojibake,「中文」變 䏿–‡ |
E4 B8 AD;誤用 Latin-1 解讀會把 3 個 bytes 各當一個字 → ä¸。bytes 沒錯,錯的是那張解碼表。
| 語言 | 誰執行 tokenize | token 種類 | 下一步產出 |
|---|---|---|---|
| HTML | Blink(渲染引擎) | DOCTYPE / start tag / end tag / comment / character / EOF,共 6 種 | DOM 樹 |
| CSS | Blink 的 CSS parser | ident / hash / string / dimension / delim … | CSSOM 樹 |
| JavaScript | V8(JS 引擎) | keyword / identifier / operator / literal / punctuator … | AST → bytecode |
data state、tag open state、attribute name state…),只管切字元、不管樹長什麼樣。<div id="root">Hi</div> 的 token 序列:
① StartTagToken { name: "div", attributes: { id: "root" } }
② CharacterToken { data: "H" }
③ CharacterToken { data: "i" }
④ EndTagToken { name: "div" }
.style、沒有 .addEventListener。不是。兩者沒有任何依賴關係。
會有這個錯覺,是因為建置流程圖通常由上往下畫,看起來像一條線。但那張圖跨了兩台機器、兩個時間點,中間是斷開的:
【機器 A:你的筆電 / CI】 build-time,做一次
.tsx → 轉譯 → bundle → 產出靜態檔
│
▼
✂️ 部署,斷開 ✂️(中間可能隔了三天)
│
▼
【機器 B:使用者的瀏覽器】 run-time,每人各做一次
收到 bytes → decode → tokenize → tree construction → DOM
.html 完全沒有轉譯,瀏覽器照樣 parse;② PHP、WordPress、Rails 前端根本沒有轉譯步驟,HTML 是後端 template 吐的字串,直接進瀏覽器。→ 轉譯不是 HTML Parsing 的前置條件。
| 比較項 | 轉譯 transpile | HTML Parsing |
|---|---|---|
| 處理對象 | JS / TS / JSX | HTML 字串 |
| 何時 | build-time | run-time |
| 誰的 CPU | 開發者/CI,做一次 | 每一個使用者,各做一次 |
| 執行者 | Babel / SWC / esbuild | Blink 的 HTML parser |
| 產物 | 還是純文字 JS | DOM 物件 |
build tool 對 index.html 只做文字層級處理(注入 <script src>/<link> 標籤 + minify),那不叫轉譯,那只是字串替換。與 [[script載入方式+前因後果]] (q) 節一致:「build 期間沒有瀏覽器、沒有 DOM,所以沒有『HTML Parsing → DOM 樹』那個過程。」
<div> 長得像 HTML<div> 與 HTML 的 <div> 是完全不同的兩個東西,走兩條互不相交的管線。
JSX 裡的 <div> |
index.html 裡的 <div> |
|
|---|---|---|
| 本質 | JavaScript 語法擴充 | HTML 標籤 |
| 誰處理 | Babel / SWC(build-time) | Blink 的 HTML parser(run-time) |
| 處理後變成 | _jsx("div", {...}) 純 JS 函式呼叫 |
HTMLDivElement 物件 |
| 會進 HTML parser 嗎 | ❌ 永遠不會 | ✅ 會 |
| 會進 V8 嗎 | ✅ 會(轉譯後就是 JS) | ❌ 永遠不會 |
// 你寫的
<div className="a">Hi</div>
// 轉譯後(這已經是純 JS,沒有任何 HTML)
_jsx("div", { className: "a", children: "Hi" })
// 瀏覽器執行它時,React 呼叫的是
document.createElement("div") // ← 直接建物件,繞過 HTML parser
React 建 DOM 靠的是 document.createElement,不是 HTML parser。 兩條線平行,不交會。
1. HTML Parsing 開始(步驟 3、4 進行中)
↓
2. 讀到 <script src="main.abc.js"> ← 這個 tag 是 build 時被注入的
↓
3. HTML Parsing 停住(無屬性)或繼續(defer)
↓
4. V8 執行那支 JS —— 它「早就」轉譯完了,在三天前的 CI 上
↓
5. React 跑起來,用 createElement 往 #root 塞東西
⚠️ 在使用者的機器上,轉譯這件事早就結束了,不參與任何順序。
main.abc123.js → build tool 把 <script src="main.abc123.js"> 注入 index.html 的文字裡。所以轉譯影響的是「HTML 裡那個 src 字串寫什麼」,不是「HTML 能不能被 parse」。就算 src 指向不存在的檔案,HTML Parsing 照樣跑完,只是那支 script 404。轉譯(build-time)→ 部署 → 使用者請求
→ Node 上執行 renderToString() ← ★ 執行,不是轉譯
→ 產出 HTML 字串 "<div class='a'>Hi</div>"
→ HTTP 傳輸 → 瀏覽器 HTML Parsing(本篇步驟 2、3、4)
中間那一步是「執行」不是「轉譯」。轉譯早在 build 就做完了,SSR 是在 run-time(伺服器端的 run-time)跑那份已經轉譯好的 JS。
一句話總結:轉譯是 build-time 的翻譯工作,HTML Parsing 是 run-time 的建構工作,對象不同、機器不同、時間不同,沒有先後依賴。
"initial" "before html" "in head" "in body" 叫 insertion mode(插入模式)。它不是 HTML 標籤、不是任何程式語言的語法,而是 WHATWG 規範文件裡的英文術語,代表樹建構狀態機的狀態。瀏覽器實作時變成 enum,例如 Blink 的 kInHeadMode。規範共 21 個:
initial before html before head in head
in head noscript after head in body text
in table in table text in caption in column group
in table body in row in cell in template
after body in frameset after frameset after after body
after after frameset
<td> 在 in row 是合法儲存格,在 in body 就是錯放、規範明文要求忽略它。HTML「寫錯也不會白屏」正是因為每個 insertion mode 都寫好了補救規則 —— 這是與 XML 最大的差別。| 層次 | 真相 |
|---|---|
你拿到的 document.body |
✅ 是 JS 物件,有原型鏈、可 . 取屬性 |
| 資料實際住在哪 | ❌ 不在 JS 堆積。真正的節點是引擎的 C++ 物件(Chrome 是 Blink) |
| JS 拿到的是什麼 | 透過 WebIDL binding 產生的包裝物件(platform object/宿主物件) |
驗證:Object.keys(document.body) 回傳 [],document.body.hasOwnProperty('tagName') 是 false —— 屬性全是 prototype 上的 getter,背後轉呼叫 C++。
extends 是誰繼承誰class 子 extends 父。左邊是子類,右邊是父類。
| 寫法 | 子(繼承者) | 父(被繼承者) | 白話 |
|---|---|---|---|
HTMLDivElement extends HTMLElement |
HTMLDivElement | HTMLElement | div 是一種 HTML 元素 |
HTMLElement extends Element |
HTMLElement | Element | HTML 元素是一種元素(SVG 元素也是) |
Element extends Node |
Element | Node | 元素是一種節點(文字、註解節點也是) |
Node extends EventTarget |
Node | EventTarget | 節點是一種可接收事件的東西(window、XHR 也是) |
三個記法:
(r-1) is-a 造句法:A extends B 讀成「A is a B」。「div is a HTMLElement」通順 ✅,反過來不通 ❌。
(r-2) 範圍大小法:父的範圍一定比子大。EventTarget > Node > Element > HTMLElement > HTMLDivElement。
(r-3) 查找方向法:JS 找屬性是從子往父往上找。在 div 上呼叫 addEventListener,一路找到 EventTarget.prototype 才找到。
(s) ★★★★★ addEventListener / removeEventListener / dispatchEvent 三個方法只定義在 EventTarget 這一層;style 在 HTMLElement;classList 在 Element;appendChild 在 Node。所以「可設寬高、可掛事件」是繼承來的能力,字串形態的 <div> 沒有這條鏈,什麼都做不了。
⚠️ 瀏覽器不是真的用 class ... extends ... 這段 JS 寫出來的;規範用的是 WebIDL 的 interface HTMLDivElement : HTMLElement,冒號左邊是子、右邊是父,與 extends 同義。
四步完全一樣,零差別。 差別只在三個變數:誰產生 HTML 字串、什麼時候產生、第一個 response 裡有沒有內容。
| 模式 | HTML 字串誰產生 | 何時 | 首個 response 有內容 | 四步 | 四步後還要做什麼 |
|---|---|---|---|---|---|
| 純靜態 HTML | 人手寫 | 寫程式時 | ✅ | 相同 | 直接 paint |
| SSG(Astro / Hugo / Jekyll / next export) | 建置工具 | build time 一次 | ✅ | 相同 | 有互動則需 hydration |
| SSR(Next.js / Nuxt / Remix) | Node 跑 renderToString |
每次 request | ✅ | 相同 | hydration 掛事件 |
| PHP / WordPress | PHP 直譯器 | 每次 request | ✅ | 相同 | 沒事了 |
| Rails / Django / Laravel | template 引擎(ERB / Jinja / Blade) | 每次 request | ✅ | 相同 | 沒事了 |
| CSR(Vite + React SPA) | 瀏覽器裡的 JS | HTML 解析完、JS 執行時 | ❌ #root 是空的 |
相同 | 還要跑一整套 JS 才有內容 |
| Streaming SSR(React 18) | Node 分段吐 | 每次 request,邊算邊送 | ✅(陸續到) | 相同 | 分段 hydration |
原本 [[script載入方式+前因後果]] (a) 節寫的「不是 CSR、SSR、SSG⋯⋯的專屬,他們全一樣,無例外」是對的,可再精確成:
「HTML Parsing 這四步無例外,但四步結束後畫面上有沒有東西,就完全不一樣了。」
CSR 的 HTML 解析反而更快(HTML 極短、節點極少)。它慢在四步跑完是白畫面,還要等 JS 下載 → V8 parse → 執行 → createElement 再建一次 DOM。瓶頸在 JS 不在 HTML Parsing。
一分鐘驗證法:Ctrl+U(伺服器原始字串)對比 F12 → Elements(現在的 DOM)。差很多且 #root 空 → CSR;幾乎一樣 → SSR/SSG/PHP/Rails。
innerHTML(要跑 HTML Parsing).md bytes → decode → Markdown 字串
→ Markdown 自己的 tokenizer → mdast
→ renderer 印成 ★HTML 字串★
→ el.innerHTML = htmlString
→ ★觸發 fragment parsing algorithm★(=本篇步驟 3、4 的片段版)
→ DOM
tokenize 兩次(Markdown 一次、HTML 一次)。
.md bytes → decode → Markdown 字串
→ remark → mdast → remark-rehype → hast(仍是 JS 物件)
→ rehype-react → React element
→ React 呼叫 document.createElement / appendChild
→ DOM
❌ 全程沒有 HTML 字串 ❌ 沒有 HTML Parsing
| 比較 | 路徑 A | 路徑 B |
|---|---|---|
| 中間有 HTML 字串 | 有 | 沒有 |
| 跑 HTML Parsing | 有(fragment parsing) | 沒有 |
| tokenize 次數 | 2 | 1 |
| XSS 風險 | 高,要接 DOMPurify | 低,天生擋掉大部分注入 |
| 建 DOM 的人 | Blink 的 HTML parser | React 呼叫 createElement |
⚠️ 要回頭修正 [[Markdown-渲染為DOM的過程]]:那篇的「Markdown 不能直接被渲染成 DOM,一定要先轉成 HTML 字串」對路徑 A 成立、對路徑 B 不成立。路徑 B 繞過 HTML 字串直接建 DOM,這正是 react-markdown 比 dangerouslySetInnerHTML 安全的原因 —— 沒有字串可以被注入。
<script> 如何打斷本篇的步驟 3、4。本篇是它的前傳。
線上版(GitHub Pages,Obsidian 之外的人可以直接看):https://abbychickenfillet-github.github.io/golang-and-TS-others-md-files/frontend-docs/html-basics/HTML-Parsing-瀏覽器拿到HTTP-response-body之後.html
[!info]- 📍 00號:整個編號序列的起點
起點:這是JS_Core_and_Runtime資料夾照編譯到執行順序編號的第一篇,畫出Parse→Ignition→TurboFan→Deoptimization整條管線地圖。
下一步:管線圖裡第一個要拆解的詞就是「引擎」本身,下一篇[[01-引擎-Engine-到底是什麼]]先把這個詞定義清楚。
比 [[機器碼與bytecode的差異]] 那篇「6. V8 的實際管線」更詳細的版本,把 Scanner/Scope Analysis、Profiling、Escape Analysis、Inline Caching、Type Specialization、Deoptimization 全部串起來。
「V8 是 Chrome 的 JS 引擎」這句話對,但不完整——V8 最早是為 Chrome 開發的沒錯,但它是一個獨立、可嵌入任何 C++ 專案的引擎,不是只綁死在 Chrome 瀏覽器裡:
libuv(處理檔案 I/O、網路等的事件迴圈)與 Node 專屬 API(fs、http…),讓 JS 可以離開瀏覽器、在伺服器上跑。「只包一層 libuv」是簡化說法,完整架構(libuv 具體是什麼、Node 還有哪些層、CSR 渲染邏輯在哪執行、純前端會不會用到 Node API)另開一篇:[[Node-js底層架構-V8-libuv-Bindings與CSR澄清]]。跟 SSR(Server-Side Rendering)的關係:Next.js、Nuxt 這類 SSR 框架,是讓 React/Vue 的渲染邏輯改在伺服器上的 Node.js執行,而 Node.js 底層就是 V8。也就是說,SSR 時伺服器產生 HTML 字串的那段 JS,走的正是本篇這一整套 Parse → Ignition → TurboFan → Deoptimization 管線,只是少了瀏覽器提供的 window/document(改用 Node 的 host 環境),並不是換了一顆完全不同的引擎。差異只在執行環境(host environment:瀏覽器 Web APIs vs Node.js APIs),不是引擎本身或編譯管線。
Node.js 在一般前端「打包」流程裡的角色(跟 SSR 不是同一件事,容易搞混)另有詳細說明,見 [[前端開發工具-打包編譯Lint與Parser]] 第 7 節。
[!warning]+ 這張圖到底在講哪個時間點?——全程都是「編譯 runtime(執行期)」
a. 下圖從「JavaScript 原始碼」一路到 Deoptimization 的每一站,都發生在瀏覽器/Node.js 載入並執行這支腳本的當下,由 V8 引擎自己完成。這裡的「編譯」指的是 JIT(Just-In-Time Compilation,即時編譯)——名字裡的 Just-In-Time 就是「執行到的那一刻才編譯」的意思。
b. 打包 buildtime(建置期)發生在這張圖的左邊界之外:Babel/tsc/SWC 把 TSX/JSX 轉成標準 JS、bundler(webpack/Vite/Rollup/Turbopack)把多個模組合併壓縮,這些都是在你的電腦或 CI 上跑完、部署之前就結束的事。V8 拿到的永遠是「已經轉譯打包完的標準 JS」,它看不到你原本寫的 TSX。
c. 用詞請跟著本資料夾的約定走:buildtime 那一段一律叫「轉譯(transpile)」與「打包(bundle)」,不要叫「編譯」;「編譯(compile)」這個詞只留給 V8 在執行期做的 AST → Bytecode → 機器碼。理由是方向不同——轉譯是高階語言 → 另一個同樣高階的語言(TSX → 標準 JS),編譯是高階 → 更低階的表示法。這組用詞的完整分辨與工具對照,主場在 [[03-前端開發工具-打包轉譯Lint與Parser-【打包buildtime】|03-前端開發工具(打包 buildtime)]],本篇只放結論。
d. 想看 buildtime 那一段的完整流程,請改讀同資料夾的 [[03-前端開發工具-打包轉譯Lint與Parser-【打包buildtime】|03-前端開發工具(打包 buildtime)]](那篇開頭有〈buildtime 全流程地圖〉表格,逐步對應到各篇);至於這些步驟是誰觸發、依什麼順序跑,主場在 [[08-npm-run-script-mechanism]] 與 [[09-npm-scripts-pre-post-生命週期鉤子]]——npm run build才是把所有 buildtime 工具串起來的那根線。打包工具選型另見 [[07-前端專案建立與打包選型-Vite與createVue與NextJS與npm鎖版本]]。
flowchart TD
A["JavaScript 原始碼"] --> B
subgraph Parse["Parse(解析)"]
B["Scanner 詞法分析<br/>→ Tokens"] --> C["Parser 語法分析<br/>→ 建立 AST"]
C --> D2{"Early Error 靜態語法檢查<br/>例:重複的參數名稱、重複的 let/const 宣告"}
D2 -- 檢查沒過 --> D2X["直接 SyntaxError<br/>連 Bytecode 都不會生成"]
D2 -- 檢查通過 --> D["Scope Analysis 範疇分析<br/>初步判定:這個變數有沒有被閉包捕獲?<br/>→ 決定放 Stack/暫存器(快)還是 Context 物件(Heap,慢)"]
end
D --> E
subgraph IgnitionBox["Ignition(直譯器)"]
E["把 AST 編成 Bytecode<br/>並立即直譯執行"] --> F["收集 Profiling Data<br/>(Feedback Vector:傳入型別、呼叫次數…)"]
end
F --> G{"判定為 Hot Code?"}
G -- 否,繼續用 Ignition 直譯 --> E
G -- 是 --> H
subgraph TurboFanBox["TurboFan(JIT 最佳化編譯器)"]
H["利用 Profiling Data 做高階優化"] --> H1["Escape Analysis<br/>物件標量替換/棧分配優化"]
H --> H2["Inline Caching<br/>內聯快取"]
H --> H3["Type Specialization<br/>型別特化"]
H1 --> I["生成高度優化的 Machine Code"]
H2 --> I
H3 --> I
end
I --> J["執行 Machine Code(極快)"]
J -- "型別突然改變<br/>(例如本來都傳 number 突然傳 string)" --> K["Deoptimization 去優化<br/>放棄 Machine Code,退回 Ignition 繼續跑 Bytecode"]
K --> E
上圖 Parse 裡新增的 Early Error 節點,例子(重複參數名稱何時合法、何時直接 SyntaxError)見 [[函式呼叫核心機制-Execution-Context-與-Parameter-Binding]] 的 (e) 節——這類錯誤在 Parse 階段就會被抓出來,根本不會走到 Ignition 生成 Bytecode。
{a,b}/[a,b])這種寫法時,Parser 具體依據的正是 ECMA-262 Destructuring Binding Patterns 這份規格條文——關係:規格文字定義「合法的解構模式長怎樣」,Parser 就是把這份規格條文轉成程式邏輯的那個實作;重要性:Scanner 產生的 Token 是 Lexical Grammar 的產物(見 [[字面量-關鍵字-識別碼基礎]]),Parser 再依這份 Syntactic Grammar 規則把 Token 組合成樹——兩份文件合起來,正好對應 Parse 階段「先切詞、再組句」的兩個步驟。這個「初步判定」跟下面 TurboFan(圖裡 H1 節點)做的 Escape Analysis(逃逸分析)+物件標量替換(Scalar Replacement) 是兩個不同層次的機制,容易被搞混,對照如下:
| Scope Analysis 的初步判定(這裡) | TurboFan 的 Escape Analysis(下方 TurboFan 階段) |
|---|
| 發生時機 | Parse 階段,每個函式都會做一次 | 只有被判定為 Hot Code、送進 TurboFan 之後才會做 |
| 判斷依據 | 靜態語法結構:AST 上有沒有內層函式引用這個變數 | 實際執行 Profile:這個物件在真正跑過的案例裡,有沒有被回傳、存到外部變數、被閉包捕獲 |
| 保守程度 | 保守——只要「可能」被捕獲就先放 Heap,確保正確性優先 | 激進——只要「證明」完全不會逃逸,可以直接連 Heap 都不配置 |
| 對物件的處理 | 二選一:整包放 Stack 或整包放 Heap Context | 可以更細:把物件拆成好幾個獨立的純量值(scalar),例如物件的每個欄位各自變成一個暫存器變數,完全跳過「配置一整個物件」這件事 |
| 目的 | 決定正確性(閉包捕獲的變數絕對不能被提早釋放) | 追求極致效能(能不進 Heap 就不進,省下配置與 GC 成本) |
一句話:Scope Analysis 是「看語法就能做的保守判斷」,Escape Analysis 是「看實際執行狀況才敢做的激進優化」——前者是每次都跑的必要步驟,後者是熱點程式碼才有的加碼優化。
var 要被提升到哪個最近的函式 scope、function 宣告要不要整個提升,以及 let/const 的暫時性死區(TDZ)範圍從哪裡到哪裡。eval / with:如果這個 scope 裡出現直接 eval() 呼叫或 with 語句,因為它們可以在執行期動態新增/改變綁定,會破壞靜態分析的前提,V8 必須把整個 scope 標記成「不可靜態優化」,退回保守、較慢的處理方式。arguments 物件:如果函式本體根本沒引用 arguments,V8 可以直接省略建立它,省一筆開銷(哪種參數列表會影響 arguments 是「mapped」還是「unmapped」,見 [[函式呼叫核心機制-Execution-Context-與-Parameter-Binding]] 的簡單參數列表說明)。"use strict" 指令或 ES Module 環境推定這段程式碼是不是 strict mode,會影響後面一系列語法限制。let/const 宣告,這類「編譯期就能確定是錯的」語法錯誤,也是在這個階段被抓出來(完整的重複參數名稱規則見 [[函式呼叫核心機制-Execution-Context-與-Parameter-Binding]] 的 (e))。V8 監控函式的呼叫次數(以及迴圈的執行次數,這種情況叫 OSR/On-Stack Replacement),超過門檻就標記成「熱點程式碼」,送去給 TurboFan 優化。沒達標的程式碼就繼續留在 Ignition 直譯執行——多數程式碼其實只跑一兩次,直接省下 TurboFan 的編譯成本。
[!question]- 「超過一次呼叫」就算 Hot Code 嗎?——不是
門檻不是「呼叫次數 > 1」這種離散計數器,而是一個額度(interrupt budget):Ignition 執行時依「跑了多少 bytecode/迴圈 back-edge 次數」持續扣掉這個額度,扣到 0 才觸發「該不該送去優化」的判斷——扣額度看的是累積執行量,不是單純數「第幾次呼叫」。也因此:一個函式只呼叫一次、但內部跑超大迴圈,一樣可能透過 OSR 變熱(見下面「實例:拿掉 return 的無窮迴圈」,就是只呼叫一次卻觸發 OSR 的案例);反過來,呼叫兩三次但函式體很小,額度通常扣不完,還是留在 Ignition。另外現代 V8 其實是多層 tiering,不是只有本篇簡化畫的 Ignition/TurboFan 兩層:Ignition(直譯)→ Sparkplug(baseline JIT,門檻很低,幾乎跑沒幾次就會編,但不做激進優化)→ Maglev(中階優化)→ TurboFan(最高階,門檻最高,要累積夠多 Profiling Data 才划算送進去)。門檻高低跟編譯成本成正比:越激進的優化器,門檻設越高。
利用 Ignition 收集到的 Profiling Data,針對這個熱點函式實際觀察到的情況做高度客製化的優化:
obj.x)如果每次遇到的物件形狀(Hidden Class)都一樣,V8 就把「這個屬性在記憶體的哪個偏移量」直接快取起來,之後同樣形狀的物件存取可以跳過查找過程直接取值——這是 V8(以及 Smalltalk 以降各種動態語言引擎)加速屬性存取的經典技巧。TurboFan 生成的優化機器碼,內部埋了「假設檢查點(deopt guard)」。一旦執行時真的違反了當初假設的前提(例如 Type Specialization 假設永遠是 number,結果這次傳進來的是 string),V8 會:
這解釋了一個常見的效能建議「同一個函式盡量固定傳同一種型別」:不是因為 JS 語言本身要求型別固定,而是因為型別一直變會不斷觸發 deoptimization,讓 V8 反覆優化又放棄,效能反而比一直用 Ignition 直譯還差。
出處:
JavaScript-practicing/smallest-divisible-digit-product.js,把return i;拿掉後實測(見 [[JavaScript-字串方法]] 的String(i)段落)
var smallestNumber = function (n, t) {
for (let i = n; ; i++) { // 沒有終止條件
const digits = String(i).split('');
const product = digits.reduce((acc, digit) => acc * Number(digit), 1);
if (product % t === 0) {
// 拿掉 return i; 之後,這裡什麼都不做
}
}
};
實測跑起來 5 秒內沒結束,被系統丟到背景程序,之後手動強制終止才停下來。對照上面的管線:
String(i)→.split()→.reduce()→i++,同時收集 Feedback Vector(i、product 一直是 number)i/product 永遠是 number,生出跳過泛用檢查的機器碼——迴圈跑更快,但邏輯上還是同一個沒有終止條件的迴圈,不會自己停digits/product 是短命值,Heap 不會爆掉,但單執行緒的 JS 沒有機會讓出控制權,process/分頁會整個卡死,只能靠外部強制終止(例如手動 kill 該 process)一句話:OSR 讓迴圈「跑得更快」,但不會讓迴圈「知道該停」——終止條件永遠得靠程式邏輯自己(return/break)交代清楚,引擎不會幫你補上。
快速結論(完整版見 [[函式呼叫核心機制-Execution-Context-與-Paramete